Compute Macro topic
ComputeMacro
ComputeMacro is a macro that executes Dart code at compile time and materializes the result into a
generated top-level variable. Annotate a top-level variable initialized with a compute(...) call,
and the macro runs the function body in an isolated process during generation, then writes the
result directly into the generated .g.dart file as a constant.
This makes it possible to derive constants from arbitrary Dart code—string manipulation, date math, build metadata, random values—without committing generated values by hand or running a build step separately.
Important
ComputeMacro currently supports top-level variables only.
Features
- ✅ Compile-Time Execution: Runs your compute body once at generation time and embeds the result
- ✅ Automatic Serialization: Primitives, lists, and maps are serialized to Dart literals without extra configuration
- ✅ Raw Code Embedding: Use
DartCodeto inject arbitrary Dart expressions into generated code - ✅ Custom Serialization: Convert any runtime value with
encode/decodecallbacks - ✅ Incremental Caching: Results are cached and only recomputed when the body or declared dependencies change
- ✅ Flexible Output: Generate
const,final, orvardeclarations with optional private names
Setup
Register the macro in your macro_context.dart file:
Future<void> setupMacro() async {
await runMacro(
macros: {
'DataClassMacro': DataClassMacro.initialize,
'ComputeMacro': ComputeMacro.initialize,
// Add more macros here
},
);
}
Basic Usage
Annotate a top-level variable whose initializer is a compute(...) call:
@Macro(ComputeMacro())
final _versionMacro = compute(() => '1.0.0');
Generates:
const version = '1.0.0';
The shorthand annotation works as well:
@computeMacro
final _versionMacro = compute(() => '1.0.0');
Generated Name Derivation
The generated variable name is derived from the annotated variable name:
| Annotated Name | Generated Name | Rule |
|---|---|---|
_versionMacro |
version |
Leading _ removed |
_appVersionMacro |
appVersion |
Trailing Macro removed |
counterMacro |
counter |
Underscore not required |
_secretMacro |
_secret |
With isPrivate modifier |
Generated Declaration Modifiers
By default a const declaration is generated. Use ComputeModifier to change the output:
// Default: const
@Macro(ComputeMacro())
final _versionMacro = compute(() => '1.0.0');
// Generates: const version = '1.0.0';
// Explicit final — required when the value can't be const
@Macro(ComputeMacro(modifier: ComputeModifier(isFinal: true)))
final _nowMacro = compute(() => DateTime.now().toString());
// Generates: final now = '...';
// var — rebuildable on every regeneration
@Macro(ComputeMacro(modifier: ComputeModifier(isVar: true)))
final _mutableMacro = compute(() => 'rebuildable');
// Generates: var mutable = '...';
// Private name + final
@Macro(ComputeMacro(modifier: ComputeModifier(isFinal: true, isPrivate: true)))
final _secretMacro = compute(() => 'hidden');
// Generates: final _secret = 'hidden';
ComputeModifier Options
| Option | Type | Default | Description |
|---|---|---|---|
isFinal |
bool |
false |
Generates final name = value; |
isVar |
bool |
false |
Generates var name = value; |
isPrivate |
bool |
false |
Prefixes the generated name with _ |
When no keyword flag is set, const is generated.
Serialization
Automatic Serialization
When no options are provided, the computed value must be sendable across an isolate boundary.
Primitives (String, int, double, bool, null), Lists, and Maps of sendable values are
serialized automatically:
@Macro(ComputeMacro())
final _listValMacro = compute(() => [1, 2, 3]);
// Generates: const listVal = [1, 2, 3];
Raw Code with DartCode
To embed raw Dart source instead of a literal value, return a DartCode. The expression is copied
into the generated code verbatim—useful for types that don't exist at generation time or can't be
serialized (e.g., Flutter's Color):
@Macro(ComputeMacro())
final _colorMacro = compute<DartCode>(
() => DartCode('Color(0xFF${123.toRadixString(16).padLeft(6, '0')})'),
);
// Generates: const color = Color(0xFF00007B);
Generating Types (Raw Declarations)
To generate whole declarations—classes, enums, mixins, extensions, typedefs, top-level
functions—at compile time, combine DartCode with ComputeModifier(isDeclaration: true). The
result is embedded verbatim as top-level code in the .g.dart file; no variable declaration is
wrapped around it:
@Macro(ComputeMacro(modifier: ComputeModifier(isDeclaration: true)))
final _userModelMacro = compute<DartCode>(
() => DartCode('''
class UserModel {
final String name;
const UserModel(this.name);
}
'''),
);
// Generates the class itself in the .g.dart file — usable like any other type
Incremental caching still applies: a hash constant is stored next to the generated code and the
body only re-executes when its hash changes. The other modifier flags (isFinal, isVar,
isPrivate) are ignored in this mode.
Custom Serialization with encode/decode
For runtime values that aren't automatically serializable (e.g., DateTime, enums), provide an
encode callback that converts the value to a string, and optionally a decode callback that turns
the encoded string back into a Dart expression:
@Macro(ComputeMacro(modifier: ComputeModifier(isFinal: true)))
final _dateMacro = compute(
() => DateTime.now(),
encode: (v) => v.toIso8601String(),
decode: (v) => "DateTime.parse('$v')",
);
// Generates something like:
// final date = DateTime.parse('2026-08-21T10:30:00.000');
Both callbacks run inside the temp execution file; the generator receives the final Dart expression.
Dependencies & Incremental Caching
Every generated variable stores a hash constant next to its value, e.g.
const _versionMacroHash = 2953536757;. On regeneration, stored hashes are compared against fresh
hashes so unchanged values are never re-executed—results stay stable across saves.
Forced Rebuilds
Caching is bypassed entirely during forced rebuilds, where all compute bodies re-execute even when nothing changed in the source:
- When a client connects and
always_rebuild_on_connect: trueis set inmacro.json - When running
macro rebuild [target]from the CLI (useful in CI to regenerate data before compiling—other tools can consume the freshly written.g.dartfile)
Without a target, macro rebuild regenerates every registered context; with a package name/id or
path, only matching contexts are rebuilt.
Without deps
The entire source file content is hashed. Any edit in the file triggers re-execution.
With deps
Only changes to the compute body itself or to the listed dependencies trigger re-execution. Dependencies can be identifiers (variables, functions, classes) or file paths—see File Dependencies:
final config = Config.parse('...');
final helper = Helper();
@Macro(ComputeMacro())
final _resultMacro = compute(
() => helper.process(config),
deps: [config, helper], // only rebuild when these change
);
Dependencies may reference same-file variables, functions, or identifiers imported from other files.
File Dependencies
String entries in deps are treated as file dependencies when they contain a path separator
(/); strings without / are rejected with a warning. Any change to a tracked file's content
re-generates the compute macro: the file's content hash is part of the variable's combined hash,
so editing the file invalidates the cached value and forces re-execution on the next generation
pass—including an explicit rebuild via macro rebuild:
@Macro(ComputeMacro())
final _appConfigMacro = compute(
() => jsonDecode(File('assets/config.json').readAsStringSync()),
deps: ['assets/config.json'], // rebuilds when the file content changes
);
Paths are resolved relative to the root of the project that owns the source file, so
'./data.json', '../shared/data.json', and absolute paths also work. Missing files log a
warning; adding the file later triggers a rebuild. Compute bodies themselves also run with the
project root as their working directory, so code like File('assets/config.json') inside the body
resolves from the project root too.
Note
Dependency files are not watched—invalidation happens whenever the source regenerates (watched
change in the same library, connect rebuilds, or macro rebuild).
Build Once
Use the macroBuildOnce sentinel to execute exactly once and cache the result permanently—ideal
for non-deterministic or expensive values you want frozen at first build:
@Macro(ComputeMacro())
final _randomNumberMacro = compute(
() => Random().nextInt(1000),
deps: macroBuildOnce, // builds once, never rebuilds
);
Note
macroBuildOnce values are still re-executed during forced rebuilds (e.g.,
always_rebuild_on_connect or macro rebuild). Use it to skip rebuilds on regular saves, not to
guarantee a permanent value across forced regenerations.
Execution Strategies
Compute bodies are executed in a temporary copy of your source file placed in the same directory, with the working directory set to the project root. Add one of the following comments at the top of the file to force a specific strategy:
| Comment | Strategy |
|---|---|
// macro-runner: isolate |
Pure Dart isolate via Isolate.spawnUri (no Flutter imports) |
// macro-runner: dart |
Dart VM via dart run <tempFile> |
// macro-runner: flutter |
Flutter test runner via flutter test <tempFile> |
When no comment is present, the strategy matches the project runner type: flutter test for Flutter
projects, dart run otherwise. Generated code from other macros in the same file is inlined into
the temp file, so compute bodies can reference generated types (e.g., data class mixins).
How It Works
- Extraction: The analyzer extracts the compute body source text from the AST along with any
encode,decode, anddepsarguments - Hashing: A hash is computed from the body plus dependencies—identifier source text and the
content hash of any file deps (or the whole file when
depsis omitted) - Cache Check: If the hash matches the stored hash in
.g.dart, the cached value is reused - Temp File: Otherwise the source file is copied to a temp file in the same directory with a
generated
main()entrypoint appended - Execution: The temp file runs in an isolate or subprocess—with the working directory set to the project root—calling each compute body
- Serialization: Results are transported back and serialized into Dart literals
- Generation: The
.g.dartfile is written with the hash constant followed by the generated variable declaration
Classes
- Macro Get started Installation Models Data Class Macro Compute Macro Asset Path Macro Shader Reloader Macro Global Configuration Write New Macro Capability
- Macro used to attach metadata to a Dart declaration.